Sorry, your browser cannot access this site
This page requires browser support (enable) JavaScript
Learn more >

Yveltals Blog

CPU缓存架构

  • 寄存器:在每个核心上,有160个用于整数和144个用于浮点的寄存器单元。访问这些寄存器只需1个时钟周期这构成了对执行核心来说最快的内存。编译器会将本地变量和函数参数分配到这些寄存器上。当使用超线程技术( hyperthreading )时,这些寄存器可以在超线程协同下共享。

  • 内存排序缓冲:MOB由一个64长度的load缓冲和36长度的store缓冲组成。这些缓冲用于记录等待缓存子系统时正在执行的操作。store缓冲是一个完全的相关性队列,可以用于搜索已经存在store操作,这些store操作在等待L1缓存的时候被队列化。在数据与缓存子系统传输时, 缓冲可以让处理器异步运转。当处理器异步读或者异步写的时候,结果可以乱序返回。为了使之与已发布的内存模型一致,MOB用于消除load和store的顺序。

  • L1 Cache:本地核心内的缓存,被分成独立的32K数据缓存32K指令缓存。访问需要3个时钟周期,并且当指令被核心流水化时, 如果数据已经在L1缓存中的话,访问时间可以忽略。

  • L2 Cache:本地核心内的缓存,被设计为L1缓存与共享的L3缓存之间的缓冲。L2缓存大小为256K,主要作用是作为L1和L3之间的高效内存访问队列。L2缓存同时包含数据和指令。L2缓存的延迟为12个时钟周期

  • L3 Cache:在同插槽的所有核心都共享L3缓存。L3缓存被分为数个2MB的段,每一个段都连接到槽上的环形网络。每一个核心也连接到这个环形网络上。地址通过hash的方式映射到段上以达到更大的吞吐量。根据缓存大小,延迟有可能高达38个时钟周期。在环上每增加一个节点将消耗一个额外的时钟周期。缓存大小根据段的数量最大可以达到20MB。L3缓存包括了在同一个槽上的所有L1和L2缓存中的数据。这种设计消耗了空间,但是使L3缓存可以拦截对L1和L2缓存的请求,减轻了各核心私有的L1和L2缓存的负担。

  • 主内存:在缓存完全没命中的情况下,DRAM通道到每个槽的延迟平均为65ns。具体延迟多少取决于很多因素,比如,下一次对同一缓存行中数据的访问将极大降低延迟,而当队列化效果和内存刷新周期冲突时将显著增加延迟。每个槽使用4个内存通道聚合起来增加吞吐量,并通过在独立内存通道上流水线化( pipelining )将隐藏这种延迟。

  • NUMA:在一个多插槽的服务器上,会使用非一致性内存访问(Non-Uniform Memory Access)。所谓的非一致性是指,需要访问的内存可能在另一个插槽上。只有当CPU访问自身直接attach内存时,才会有较短的响应时间(Local Access)。而如果需要访问其他CPU attach的内存的数据时,就需要通过inter-connect通道访问,响应时间就相比之前变慢了(Remote Access)。

CPU Cache 是由很多个 Cache Line 组成,每个Cache Line大小为64KB。Cache Line 是 CPU从内存读写数据的基本单位,每次从内存中读取完整的一个Cache Line到Cache进行读写操作。

Cache Line从Cache写回内存由两种方案:

  • 写直达 Writer Through,把数据同时写入内存和Cache中;性能较差
  • 写回 Writer Back,新的数据仅仅被写⼊Cache⾥,只有当修改过的Cache Line被换出时才需要写到内存中,减少数据写回内存的频率;性能较好

Cache Coherence

现在 CPU 都是多核的,由于 L1/L2 Cache 是多个核心各自独有的,那么会带来多核心的缓存一致性(Cache Coherence)的问题,如果不能保证缓存一致性的问题,就可能造成结果错误。假设 A 和 B 号核心同时运行两个线程,且都操作共同的变量 i(采用写回策略),将导致不一致。

要想实现缓存一致性,关键是要满足 2 点:

  1. 写传播:当某个 CPU 核心发生写入操作时,需要把该事件广播通知给其他核心;
  2. 事物的串行化:保障数据是真正一致的,程序在各个不同的核心上运行的结果也是一致的;

MESI 协议

MESI 协议用四个状态来标记 Cache Line 四个不同的状态:

  • Modified,已修改

  • Exclusive,独占

  • Shared,共享

  • Invalidated,已失效

已修改代表该 Cache Block 上的数据已经被更新过,但是还没有写到内存里。已失效表示 Cache Block 里的数据已经失效了,不可以读取该状态的数据。

独占共享状态都代表 Cache Block 里的数据是干净的,即这时候 Cache Block 里的数据和内存是一致的。

独占:数据只存储在一个 CPU 核心的 Cache 里,可以直接自由地写入,而不需要通知其他 CPU 核心。如果有其他核心从内存读取了相同的数据到各自的 Cache ,独占状态下的数据就会变成共享状态。

共享:相同的数据在多个 CPU 核心的 Cache 里都有,所以更新 Cache 时不能直接修改,而是要先向所有的其他 CPU 核心广播一个请求,要求先把其他核心的 Cache 中对应的 Cache Line 标记为无效状态,然后再更新当前 Cache 里面的数据。

对于不同状态触发的事件操作,可能是来自本地 CPU 核心发出的广播事件,也可以是来自其他 CPU 核心通过总线发出的广播事件。下图是 MESI 协议的状态图:

MESI协议解决了多核环境cache多层级带来的一致性问题,CPU核心对本地cache的修改可以被其它核心观测到,使得cache层对于CPU来说是透明的。单一地址变量的写入,可以以线性的逻辑进行理解。

MESI 带来的问题

False Sharing

由于 CPU 以 64KB 的 Cache Line 为最小单位从内存中加载数据,可能会出现这样的问题:

  • 假设变量 a 和 b 位于同一个 Cache Line 中,当前 CPU0 和 CPU1 都将这个 Cache Line 加载到 Cache,CPU0 只修改变量 a,CPU1 只读取变量 b。
  • 当 CPU0 修改 a 时,CPU1 的 Cache Line 会变为 Invalid 状态,即使 CPU1 并没有修改 b,这会导致 CPU1 从内存或其它核心重新加载 Cache Line 中的所有变量,影响性能。

解决 False Sharing 的方法是字节填充,在 a 和 b 之间填充无意义的变量,使一个变量单独占用一个 Cache Line。

RMW操作不安全

MESI协议无法保证并发场景下RMW操作(Read-Modify-Write操作,先读取再计算最后写回)的安全性。

例如两个线程都执行 i++,i的初值为 0。

  • 两个核心执行 Read 操作的时候,CPU0 和 CPU1 的 cache 都是 Shared 状态
  • 两个核心各自修改自己的寄存器,然后准备将寄存器值需要写入到 cache
  • 假设,CPU1 先完成写入 cache 的操作,状态变为 Modified,导致 CPU0 Cache 状态已经变成了 Invalid
  • CPU0 需要重新发出 Read 请求读取 Cache Line 后继续写入,这时 CPU1 会响应自己修改后的最新 Cache Line,同时写回主存
  • CPU0 获得 CPU1 修改后的 Cache Line,将i值1写入,导致覆盖掉了CPU1 的自增结果
  • 最终两个i++操作之后,i的值还是 1

使用LOCK前缀指令可以解决这个问题,下文会详细介绍。

性能问题

CPU修改Cache Line中的变量时,要通过总线发送Invalidate信息给远程CPU,等到远程CPU返回ACK,才能进行继续修改。如果远程的CPU比较繁忙,会带来更大的延迟。

为了解决这个问题,引入了Store buffer。

image

Store buffer和Invalidate queue

Store buffer

在CPU和L1Cache之间引入Store buffer来对cache line的写操作进行优化。

  • 写操作写入Store Buffer后就返回,同时向其它核心发出Invalidate消息。
  • 等CPU的Invalidate ACK消息返回后再异步写进Cache Line
  • 核心从Cache中读取前都要先扫描自己的Store buffers来确认是否存在目标行。有可能当前核心在这次操作之前曾经写入cache,但该数据还没有被刷入cache(之前的写操作还在 store buffer 中等待)。
  • Store buffer对写操作有明显的优化,但也带来CPU内存一致性的问题(出现内存重排)。

注意,虽然CPU可以读取其之前写入到本地Store buffer中的值,但其它核心并不能在该核心将Store buffer中的内容Flush到Cache之前看到这些值。即

  • Store buffer是不能跨核心访问的,CPU看不到其它核心的 Store buffer。
  • Store buffer刷入cache前,其它核心最多只知道cache line已失效(收到Invalidate消息)

Invalidate queue

Store Buffer 容量是有限的,当Store Buffer满了之后CPU核心还是要卡住等待Invalidate ACK。

  • 接收方响应Invalidate ACK耗时的主要原因是核心需要先将自己cache line状态修改后才响应ACK
  • 如果一个核心很繁忙或者处于S状态的副本特别多,可能所有CPU都在等它的ACK。

CPU优化这个问题的方式是引入Invalid queue,CPU核心先将Invalidate消息放到这个队列并立刻响应Invalidate ACK,但不马上处理,消息只是会被推invalidation队列,并在之后尽快处理。

因此核心可能并不知道在它Cache里的某个Cache Line是Invalid状态的,因为Invalidation队列包含有收到但还没有处理的Invalidation消息。

CPU在读取数据的时候,并不像Store buffer那样读取Invalidate queue。

引入Store buffer和Invalidate queue之后,不同核心上运行的线程对cache line的并发读写可以会出现问题,即修改不会同步被其它核心处理。

Memory Consistency

下面两个goroutine执行时,会出现几种可能的结果:

1
2
3
4
5
6
7
8
9
var x, y int
go func() {
x = 1 // A1
fmt.Print("y:", y, " ") // A2
}()
go func() {
y = 1 // B1
fmt.Print("x:", x, " ") // B2
}()

很显然的几种结果:

1
2
3
4
y:0 x:1
x:0 y:1
x:1 y:1
y:1 x:1

令人意外的结果:

1
2
x:0 y:0
y:0 x:0

出现这两种意外结果的原因就是内存重排 Memory Reordering

内存重排是指内存读写指令的重排,原因分为软件层面的编译器重排和硬件层面的CPU重排

编译器重排

编译器(compiler)的工作之一是优化我们的代码以提高性能。这包括在不改变程序行为的情况下重新排列指令。因为compiler不知道什么样的代码需要线程安全(thread-safe),所以compiler假设我们的代码都是单线程执行(single-threaded),并且进行指令重排优化并保证是单线程安全的。因此,在不需要compiler重新排序指令的时候,需要显式告诉编译器,这里不需要重排。

对于这段C语言代码

1
2
3
4
5
int a, b;
void foo(void){
a = b + 1;
b = 0;
}

编译优化后的逻辑可以看作如下形式:

1
2
3
4
5
6
int a, b;
void foo(void){
register int reg = b;
b = 0;
a = reg + 1;
}

这种 compiler reordering 在某些情况下可能会引入问题,例如用一个全局变量flag标记共享数据data是否就绪。2个线程,一个用来更新data的值。使用flag标志data数据已经准备就绪,其他线程可以读取。另一个线程一直调用read_data(),等待flag被置位,然后返回读取的数据data。

如果compiler产生的汇编代码是flag比data先写入内存,即使是单核系统上也会有问题,如:

  • 在flag置1之后,data写入之前,系统发生抢占
  • 另一个线程发现flag已经置1,认为data的数据已经就绪
  • 但实际上读取data的值并不是合法值
1
2
3
4
5
6
7
8
9
10
11
12
int flag, data;
void write_data(int value) {
data = value;
flag = 1; // data is ready.
}
void read_data(void) {
int res;
while (flag == 0); // wait for data to be ready.
res = data; // read data.
flag = 0;
return res;
}

为什么compiler还会这么操作呢?因为,compiler不知道data和flag之间有严格的依赖关系,这种逻辑关系是我们人为引入的。如下面代码的情况,如果当前只有这一个线程读写X变量,程序的运行结果是符合预期的。但在多线程环境下,假如另一个CPU核心执行了 X = 0,输出就不一样了。

1
2
3
4
5
6
7
8
9
// 原代码
X = 0
for i in range(100):
X = 1
print X
// 编译器优化后
X = 1
for i in range(100):
print X

为了防止 编译器重排 导致多线程程序逻辑错误的情况,需要在编码时手动插入 内存屏障,禁止编译器调整屏障两边的读写操作。即,内存屏障也具有 编译器屏障 的效果。

1
2
3
4
5
6
7
#define barrier() __asm__ __volatile__("": : :"memory")
int a, b;
void foo(void){
a = b + 1;
barrier();
b = 0;
}

barrier()就像是代码中的一道不可逾越的屏障,barrier前的 load/store 操作不能跑到barrier后面;同样,barrier后面的 load/store 操作不能跑到barrier之前。

CPU重排

CPU重排是一种运行时的内存重排序。

由于CPU Store Buffer/Invalidate queue的存在,CPU对一个变量的赋值操作可能无法被另一个核心及时观察到,造成出现开头内存重排的另外两种令人意外的结果。这是CPU重排现象的一种原因。

另外,CPU还有流水线、分支预测、乱序等特性,他们的目的都是最大化提高CPU利用率,但也带来了CPU重排的现象,造成 内存一致性memory consistency 问题,这些是MESI协议解决不了的。

写屏障

1
2
3
4
5
6
7
8
9
10
11
12
13
a = 0
flag = false
func runInCpu0() {
a = 1
flag = true
}

func runInCpu1() {
while (!flag) {
continue
}
print(a)
}

假设有如下执行步骤:

  • 假定当前a存在于cpu1的cache中,flag存在于cpu0的cache中,状态均为E。
  • cpu1先执行while(!flag),由于flag不存在于它的cache中,所以它发出Read flag消息
  • cpu0执行a=1,它的cache中没有a,因此它将a=1写入Store Buffer,并发出Invalidate a消息
  • cpu0执行flag=true,由于flag存在于它的cache中并且状态为E,所以将flag=true直接写入到cache,状态修改为M
  • cpu0接收到Read flag消息,将cache中的flag=true发回给cpu1,状态修改为S
  • cpu1收到cpu0的Read Response:flat=true,结束while(!flag)循环
  • cpu1打印a,由于此时a存在于它的cache中a=0(且认为是合法状态),所以打印出来了0
  • cpu1此时收到Invalidate a消息,将cacheline状态修改为I,但为时已晚
  • cpu0收到Invalidate ACK,将Store Buffer中的数据a=1刷到cache中

从结果看,两个写入操作发生了重排序,代码好像变成了:

1
2
3
4
func runInCpu0() {
flag = true
a = 1
}

CPU从软件层面提供了 写屏障write memory barrier 指令来解决上面的问题,Linux将CPU写屏障封装为smp_wmb()函数。

写屏障解决上面问题的方法是等待当前Store Buffer中的数据同步刷到cache后再执行屏障后面的写入操作。

加入写屏障后,当cpu0执行flag=true时,由于Store Buffer中有a=1还没有刷到cache上,所以会先等待a=1刷到cache之后再执行flag=true,当cpu1读到flag=true时,a也就=1了。

1
2
3
4
5
6
7
8
9
10
11
12
13
a = 0
flag = false
func runInCpu0() {
a = 1
smp_wmb()
flag = true
}
func runInCpu1() {
while (!flag) {
continue
}
print(a)
}

读屏障

现在考虑Invalidate queue带来的问题,还是以上面的代码为例。

我们假设a在CPU0和CPU1中,且状态均为S,flag由CPU0独占

  • CPU0执行a=1,因为a状态为S,所以它将a=1写入Store Buffer,并发出Invalidate a消息
  • CPU1执行while(!flag),由于其cache中没有flag,所以它发出Read flag消息
  • CPU1收到CPU0的Invalidate a消息,并将此消息写入了Invalid Queue,接着就响应了Invlidate ACK
  • CPU0收到CPU1的Invalidate ACK后将a=1刷到cache中,并将其状态修改为了M
  • CPU0执行到smp_wmb(),由于Store Buffer此时为空所以就往下执行了
  • CPU0执行flag=true,因为flag状态为E,所以它直接将flag=true写入到cache,状态被修改为了M
  • CPU0收到了Read flag消息,因为它cache中有flag,因此它响应了Read Response,并将状态修改为S
  • CPU1收到Read flag Response,此时flag=true,所以结束了while循环
  • CPU1打印a,由于a存在于它的cache中且状态为S,所以直接将cache中的a打印出来了,此时a=0,这显然发生了错误。
  • CPU1这时才处理Invalid Queue中的消息将a状态修改为I,但为时已晚

为了解决上面的问题,CPU提供了 读屏障指令,Linux将其封装为了smp_rwm()函数。

1
2
3
4
5
6
7
func runInCpu1() {
while (!flag) {
continue
}
smp_rwm()
print(a)
}

CPU执行到smp_rwm()时,会将Invalid Queue中的数据处理完成后再执行屏障后面的读取操作,这就解决了上面的问题了。

除了上面提到的 读屏障写屏障 外,还有一种 全屏障Full barrier,它其实是读屏障和写屏障的综合体,兼具两种屏障的作用,在Linux中它是smp_mb()函数。

前面提到的 LOCK前缀指令 其实兼具了内存屏障的作用。

CPU重排问题靠是MESI协议解决不了的,还是需要使用内存屏障技术,在需要时同步Flush Store buffer和Invalidate queue。

CPU内存一致性模型

cache一致性和内存一致性有什么区别呢?

可以简单地认为,cache一致性关注的是多个CPU看到一个地址的数据是否一致,而内存一致性更多关注的是多个CPU看到多个地址数据读写的次序是否一致。

针对内存一致性问题,提出内存一致性模型的概念,方便软件工程师在不理解硬件的情况下,基于硬件的内存一致性模型编写正确的并行代码。

内存一致性模型讨论的是,在引入Store buffer等CPU乱序机制后,指令流的定义顺序与CPU实际执行顺序的一致性问题。

目前有多种内存一致性模型。不同的处理器平台,本身的内存模型也有强Strong弱Weak之分。

  • 顺序存储模型 Sequential Consistency
    • 最强一致性,没有乱序存在,严重限制硬件对CPU的优化
  • 完全存储定序 Total Store Order
    • Strong Memory Model,强内存模型,如X86/64,保证每条指令的acquire/release语义
  • 部分存储定序 Part Store Order
  • 宽松存储模型 Relax Memory Order
    • Weak Memory Model,弱内存模型,如ARM/PowerPC

分类

顺序存储模型SC

多核心会完全按照指令流的顺序执行代码。

  • C1与C2的指令虽然在不同的CORE上运行,但是C1发出来的访问指令是顺序的,同时C2的指令也是顺序
  • 虽然这两个线程跑在不同的CPU上,但是在顺序存储模型上,其访问行为与UP(单核)上是一致的 所以在顺序存储模型上是不会出现内存访问乱序的情况。

完全存储定序TSO

TSO会利用Store buffer提高CPU利用率,并且严格按照FIFO的顺序将Store buffer中的Cache Line写入主存。

1
2
3
S1:store flag = set
S2:load r1 = data
S3:store b = set

在顺序存储模型中,S1肯定会比S2先执行。

但是加入了Store buffer之后,S1将指令放到了Store buffer后会立刻返回,这个时候会立刻执行S2。S2是read指令,CPU必须等到数据读取到r1后才会继续执行。这样很可能S1的store flag=set指令还在Store Buffer上,而S2的load指令可能已经执行完(特别是data在Cache上存在,而flag没在Cache中的时候。这个时候CPU往往会先执行S2减少等待时间)。

加入了store buffer之后,内存一致性模型就发生了改变。

完全存储定序TSO要求,严格按照FIFO的次序将数据发送到主存(先进入Store Buffer的指令数据必须先于后面的指令数据写到存储器中),这样S3必须要在S1之后执行。

CPU只保证Store指令的存储顺序,即完全存储定序(TSO)。而对于Store指令后面有Load指令的情况,CPU可能会将Load指令提前执行完成。这就是所谓的 Store-Load 乱序,x86 唯一的乱序就是这种。

关于x86架构的强内存序:

  • x86架构不存在invalidate queue,因此不存在 Load-Load/Load-Store乱序
  • store buffer严格按照FIFO,因此不存在Store-Store乱序
  • 由于store buffer,存在Store-Load乱序

部分存储定序PSO

芯片设计人员并不满足TSO带来的性能提升,于是他们在TSO模型的基础上继续放宽内存访问限制,允许CPU以非FIFO来处理Store buffer缓冲区中的指令。CPU只保证 地址相关指令 在Store buffer中才会以FIFO的形式进行处理,而其他的则可以乱序处理,所以这被称为部分存储定序(PSO)。

这里的地址相关指令,指多个指令操作的地址相同或者有前后关联。例子中L1和L2就是地址无关指令。

这时可能出现这样的执行顺序 S2 L1 L2 S1 ,即 Store-Store乱序。由于S2可能会比S1先执行,从而会导致C2的r2寄存器获取到的data值为0。

宽松存储模型RMO

在PSO的模型的基础上,更进一步的放宽了内存一致性模型,不仅允许Store-Load,Store-Store乱序。还进一步允许Load-Load,Load-Store乱序。只要是地址无关的指令,在读写访问时都可以打乱所有Load/Store的顺序,这就是宽松内存模型(RMO)。

RMO内存模型里乱序出现的可能性会非常大,这是一种乱序随处可见的内存一致性模型。

Acquire/Release 语义

Acquire语义是指从共享内存中读取数据,无论是读-修改-写还是直接load。这些操作都被归类为read acquire。 Acquire语义保证了read-acquire操作不会被程序后续的任何read或write操作打乱顺序。

Release语义是指向共享内存中写入数据,无论是读-修改-写还是直接store。这些操作都被归类为write-release。 Relase语义保证了write-release操作不会被程序前面的任何read或write操作打乱顺序。

x86只有Store-Load乱序,因此x86上每条指令都符合acquire和release语义。

内存模型的重要性

内存一致模型,或称内存模型,是一份语言用户与语言自身、语言自身与所在的操作系统平台、 所在操作系统平台与硬件平台之间的契约。它定义了并行状态下拥有确定读取和写入的时序的条件, 并回答了一个共享变量是否具有足够的同步机制来保障一个线程的写入能否发生在另一个线程的读取之前这个问题。

在一份程序被写成后,将经过编译器的转换与优化、所运行操作系统或虚拟机等动态优化器的优化,以及 CPU 硬件平台对指令流的优化才最终得以被执行。这个过程意味着,对于某一个变量的读取与写入操作,可能 被这个过程中任何一个中间步骤进行调整,从而偏离程序员在程序中所指定的原有顺序。 没有内存模型的保障,就无法正确的推演程序在最终被执行时的正确性。

内存模型的策略同样有着长期影响,并且直接决定了程序的可移植性和可维护性。 例如,过强的内存模型将约束硬件和编译器优化的空间,从而严重降低程序性能上限; 已经选择了强内存模型的硬件体系结构,无法在不破坏兼容性的情况下向更弱的内存模型进行迁移, 这种兼容性破坏所带来的代价就是要求其平台上的程序重新实现其源码。

这种横跨用户、软件与硬件三大领域的主题使得内存模型的设计愿景变得异常的困难,至今仍是一个开放的研究问题。

如今主流的编程语言的内存模型都是顺序一致性(SC)模型,它为开发人员提供了一种理想的SC机器(虽然实际中的机器并非SC的),程序是建构在这一模型之上的。开发人员要想实现出正确的并发程序,还必须了解编程语言封装后的同步原语以及他们的语义。只要程序员遵循并发程序的同步要求合理使用这些同步原语,那么编写出来的并发程序就能在非SC机器上跑出顺序一致性的效果。

内存屏障

芯片设计人员为了尽可能的榨取CPU的性能,引入了乱序的内存一致性模型,这些内存模型在多线程的情况下很可能引起软件逻辑问题。

为了解决在有些一致性模型上可能出现的内存访问乱序问题,芯片设计人员提供给了内存屏障指令。 内存屏障的最根本的作用就是提供一个机制,要求CPU在这个时候必须以顺序存储一致性模型的方式来处理Load与Store指令,这样才不会出现内存不一致的情况。

对于TSO和PSO模型,内存屏障只需要在Store-Load/Store-Store时需要(写内存屏障),最简单的一种方式就是内存屏障指令必须保证Store buffer数据全部被清空的时候才继续往后面执行,这样就能保证其与SC模型的执行顺序一致。

而对于RMO,在PSO的基础上又引入了Load-Load与Load-Store乱序。RMO的读内存屏障就要保证前面的Load指令必须先于后面的Load/Store指令先执行,不允许将其访问提前执行。

memory barrier 是必须的。一个 Store barrier 会把 Store Buffer flush 掉,确保所有的写操作都被应用到 CPU 的 Cache。一个 Read barrier 会把 Invalidation Queue flush 掉,也就确保了其它 CPU 的写入对执行 flush 操作的当前这个 CPU 可见。

Intel为此提供四种内存屏障指令:

  • sfence ,实现Store Barrier。将Store Buffer中缓存的修改刷入L1 cache中,使得其他cpu核可以观察到这些修改,而且之后的写操作不会被调度到之前,即sfence之前的写操作一定在sfence完成且全局可见;
  • lfence ,实现Load Barrier。 Flush Invalidate Queue,强制读取L1 cache,而且lfence之后的读操作不会被调度到之前,即lfence之前的读操作一定在lfence完成(并未规定全局可见性);
  • mfence ,实现Full Barrier。 同时刷新Store Buffer和Invalidate Queue,保证了mfence前后的读写操作的顺序,同时要求mfence之后的写操作结果全局可见之前,mfence之前的写操作结果全局可见;
  • lock 用来修饰当前指令操作的内存只能由当前CPU使用,若指令不操作内存仍然有用,因为这个修饰会让指令操作本身原子化,而且自带Full Barrior效果;还有指令比如IO操作的指令、CHG等原子交换的指令,任何带有LOCK前缀的指令以及CPUID等指令都有内存屏障的作用。

X86-64下仅支持一种指令重排:Store-Load ,即读操作可能会重排到写操作前面,同时不同线程的写操作不保证全局可见,这个问题只能用mfence解决,不能靠组合sfence和lfence解决。

解决 cache coherence 的协议(MESI)并不能解决 CAS 类的问题。同时也解决不了 memory consistency,即不同内存地址读写的可见性问题,要解决 memory consistency 的问题,需要使用 memory barrier 之类的工具。

并发编程

如果我们的缓存总是保证一致性,那么为什么我们在写并发程序时要担心可见性?这是因为核心为了得到更好的性能,对于其它线程来说,可能会出现数据修改的乱序。这么做主要有两个理由:

  1. 编译器在生成程序代码时,为了性能,可能让变量在寄存器中存在很长的时间,例如,变量在一个循环中重复使用。如果我们需要这些变量在核心之间可见,那么变量就不能在寄存器分配。可以添加 volatile 关键字达到这个目标。但是 volatile 并不能保证让编译器不重排我们的指令,因此需要使用内存屏障。

  2. 一个线程写了一个变量,然后很快读取,有可能从读缓冲中获得比缓存子系统中最新值要旧的值。这对于遵循单写入者原则(Single Writer Principle)的程序来说没有任何问题,但是对于 Dekker 和Peterson锁算法就是个很大问题。为了克服这一点,并且确保最新值可见,线程不能从本地读缓冲中读取值。可以使用屏障指令,防止下一个读操作在另一线程的写操作之前发生。

误区

回到作为并发算法中的一部分的“刷新缓存”误区上,我想,可以说我们永远不会在用户空间的程序上“刷新”CPU缓存。我相信这个误区的来源是由于在某些并发算法需要刷新、标记或者清空store缓冲以使下一个读操作可以看到最新值。为了达到这点,我们需要内存屏障而非刷新缓存。

这个误解的另一个可能来源是,L1缓存,或者 TLB,在上下文切换的时候可能需要根据地址索引策略进行刷新。ARM,在ARMv6之前,没有在TLB条目上使用地址空间标签,因此在上下文切换的时候需要刷新整个L1缓存。许多处理器因为类似的理由需要L1指令缓存刷新,在许多场景下,仅仅是因为指令缓存没有必要保持一致。上下文切换消耗很大,除了污染L2缓存之外,上下文切换还会导致TLB和/或者L1缓存刷新。Intel x86处理器在上下文切换时仅仅需要TLB刷新。

参考链接

https://zhuanlan.zhihu.com/p/43526907